Skip to content

NIFI-15931: Fetching parameter values in the Connector canvas.#11263

Merged
rfellows merged 3 commits into
apache:mainfrom
mcgilman:NIFI-15931
May 22, 2026
Merged

NIFI-15931: Fetching parameter values in the Connector canvas.#11263
rfellows merged 3 commits into
apache:mainfrom
mcgilman:NIFI-15931

Conversation

@mcgilman
Copy link
Copy Markdown
Contributor

Summary

JIRA: NIFI-15931

Surfaces parameter values inline in the Connector canvas so property tables and the codemirror parameter tip can resolve #{param} references without leaving the canvas. Read-only by design: values render in tooltips, but the "Go to Parameter" affordance is hidden because connector-managed parameter contexts are not navigable.

Description

Backend

  • New REST endpoint GET /connectors/{connectorId}/flow/process-groups/{processGroupId}/parameter-context on ConnectorResource. Requires READ on the connector, returns a ParameterContextEntity for the PG's bound context, or 204 No Content when no context is bound. Sensitive values are masked via the existing DtoFactory path.
  • NiFiServiceFacade#getConnectorParameterContext and its StandardNiFiServiceFacade implementation look up the ConnectorNode, locate the target ProcessGroup, and return its bound ParameterContext.
  • DtoFactory#createParameterDto now walks the in-memory inheritance graph of the supplied ParameterContext to populate containingParameterContext (with cycle protection), falling back to the supplied ParameterContextLookup only when no in-memory match is found. This is required because connector-managed parameter contexts are not registered in the global ParameterContextDAO, so the previous lookup-only path returned "Unable to find Parameter Context …" for any inherited parameter on a connector PG.

Frontend

  • ConnectorService.getConnectorParameterContext(connectorId, processGroupId) wraps the new endpoint, mapping 204 to null so the caller can distinguish "no context" from a load failure.
  • New NgRx action set (loadConnectorParameterContext / Success / Failure) and parameterContext slice on ConnectorCanvasState. Selectors expose the current value to dialogs.
  • A single effect, loadConnectorParameterContextOnLoadSuccess$, listens to both loadConnectorFlowSuccess and loadConnectorControllerServicesSuccess, normalizes their payloads, filters out null processGroupIds, and dedupes via distinctUntilChanged on (connectorId, processGroupId). This covers the deep-link case where the user lands directly on /connectors/:id/canvas/:pgId/controller-services (the CS route is a sibling of the canvas route, not a child, so it does not activate the canvas component) without firing duplicate fetches when polling or the canvas → CS transition refires within the same PG.
  • The action carries an errorContext: ErrorContextKey chosen by the trigger (CONNECTOR_CANVAS for canvas, CONTROLLER_SERVICES for CS-listing) so loadConnectorParameterContext$ can surface failures on the page the user is actually looking at via addBannerError.
  • A shared helper bindConnectorParameterContext(store, teardown$, apply) wires the current parameter context onto the read-only EditProcessor and EditControllerService dialogs for the lifetime of dialogRef.afterClosed(). goToParameter is intentionally left undefined; the property table now hides the "Go to Parameter" menu item whenever the callback is missing (property-table.component.ts).
  • @Input() goToParameter?: (parameter: string) => void is now optional on EditProcessor and EditControllerService to match.
  • The property-value-tip template and the codemirror parameter-tip template render explicit empty-state copy ("No value set" / "Empty string set") so users can tell unset parameters from values that simply weren't fetched. Both null and undefined are handled because the wire format prunes nulls.
  • Canvas reducer also resets cached component data + parameter context whenever connectorId (in addition to processGroupId) changes, so navigating between connectors doesn't briefly leak stale shapes.

Test fixture

  • pages/connectors/testing/parameter-context-fixture.ts centralizes the as unknown as ParameterContextEntity cast for test fixtures.

Tests

  • DtoFactoryTest: 7 new cases covering null/matching IDs, direct and transitive inheritance, fallback to lookup, cyclic inheritance graphs, and sensitive value masking.
  • StandardNiFiServiceFacadeTest: happy path returning the entity, null when no context is bound, and ResourceNotFoundException when the PG cannot be located.
  • TestConnectorResource: 200 / 204 / AccessDeniedException cases for the new endpoint.
  • Frontend (Vitest): ConnectorService (200 vs 204), connectorCanvasReducer parameter context handling and connector-id reset behavior, ConnectorCanvasEffects (load on canvas + CS deep link, polling/transition dedupe, failure routing per trigger), ConnectorControllerServicesEffects parameter-context binding in the read-only dialog, and property-table canGoToParameter with/without callback. Tooltip rendering for null/undefined/''/non-empty across both property-value-tip and parameter-tip.

@mcgilman mcgilman added the ui Pull requests for work relating to the user interface label May 19, 2026
@rfellows
Copy link
Copy Markdown
Contributor

reviewing...

Copy link
Copy Markdown
Contributor

@rfellows rfellows left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid implementation overall — the in-memory graph walk is the right approach for connector-managed contexts, and the frontend effect/reducer/dialog wiring is clean. A few things to address before merging, noted inline.

return fromGraph;
}

return parameterContextLookup.getParameterContext(sourceId);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveContainingParameterContext can return null here when both the in-memory graph walk and the lookup come up empty. The connector path in StandardNiFiServiceFacade passes ParameterContextLookup.EMPTY, whose getParameterContext unconditionally returns null. If a parameter carries a parameterContextId that is not reachable in the in-memory graph (e.g. data inconsistency after a reconciliation), this method returns null, and the caller on line 1625 immediately dereferences it with .getIdentifier(), producing an NPE.

Suggested fix — guard the return value:

final ParameterContext result = parameterContextLookup.getParameterContext(sourceId);
return result != null ? result : parameterContext;

Falling back to parameterContext (treating the parameter as locally defined) is conservative and safe. Alternatively, throw an IllegalStateException with a descriptive message so the inconsistency surfaces clearly rather than crashing silently.


// Connector-managed parameter contexts (and any contexts they inherit from) are not registered with the
// global flow's ParameterContextManager. The DTO factory now resolves a parameter's source context by
// walking the in-memory inheritance graph on the supplied context, so an empty lookup is sufficient here.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: "now resolves" is patch-relative — it describes the code relative to what it used to do rather than its permanent contract. Comments should explain the invariant, not the change (those belong in the commit message).

Suggested rewrite:

// Connector-managed parameter contexts are not registered with the global ParameterContextManager,
// so the DAO-backed lookup would fail for any inherited parameter. The in-memory inheritance graph
// reachable from the supplied context is sufficient to resolve parameter source contexts for
// connector-managed flows, making an empty lookup safe here.

assertEquals(externalId, dto.getParameterContext().getId());

verify(lookup).getParameterContext(externalId);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test covers the fallback path when the lookup returns a valid context, but there is no companion test for the case where both the graph walk and the lookup return null — i.e. ParameterContextLookup.EMPTY, which is exactly what the connector path passes. Without it, the behavior of resolveContainingParameterContext when both paths come up empty is unspecified by the test suite, and the NPE at the call site (line 1625) would go undetected.

Suggested addition alongside the null-guard fix:

@Test
void testCreateParameterDtoFallsBackToCurrentContextWhenSourceNotReachableInGraphAndLookupIsEmpty() {
    final String contextId = "context-1";
    final String externalId = "context-external";

    final ParameterContext parameterContext = createMockParameterContext(contextId, "context-1-name", Collections.emptyList());

    final Parameter parameter = new Parameter.Builder()
            .name("param-name")
            .value("param-value")
            .parameterContextId(externalId)
            .build();

    final DtoFactory dtoFactory = newDtoFactoryForParameters();
    final ParameterDTO dto = dtoFactory.createParameterDto(
            parameterContext, parameter, mock(RevisionManager.class), ParameterContextLookup.EMPTY);

    // Source context not found in graph or lookup; falls back to treating as local
    assertFalse(dto.getInherited());
    assertEquals(contextId, dto.getParameterContext().getId());
}

(Exact assertion depends on the chosen fallback behavior, but the test is needed either way to pin the contract.)

assertEquals(missingId, dto.getParameterContext().getId());

verify(lookup).getParameterContext(missingId);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cycle test covers mutual recursion (child → parent → child), but the visited set in findInheritedParameterContext also guards against redundant traversal in diamond inheritance (A inherits B and C, both inherit D). That second behavior is untested. Without it, a future change that accidentally removes or weakens the visited-set guard would not be caught.

Suggested addition:

@Test
void testCreateParameterDtoResolvesSourceContextFromDiamondInheritanceGraph() {
    final String contextAId = "context-a";
    final String contextBId = "context-b";
    final String contextCId = "context-c";
    final String contextDId = "context-d";

    final ParameterContext contextD = createMockParameterContext(contextDId, "context-d-name", Collections.emptyList());
    final ParameterContext contextB = createMockParameterContext(contextBId, "context-b-name", List.of(contextD));
    final ParameterContext contextC = createMockParameterContext(contextCId, "context-c-name", List.of(contextD));
    final ParameterContext contextA = createMockParameterContext(contextAId, "context-a-name", List.of(contextB, contextC));

    final Parameter parameter = new Parameter.Builder()
            .name("param-name")
            .value("param-value")
            .parameterContextId(contextDId)
            .build();

    final DtoFactory dtoFactory = newDtoFactoryForParameters();
    final ParameterDTO dto = dtoFactory.createParameterDto(
            contextA, parameter, mock(RevisionManager.class), ParameterContextLookup.EMPTY);

    assertTrue(dto.getInherited());
    assertEquals(contextDId, dto.getParameterContext().getId());
}

errorContext: ErrorContextKey.CONNECTOR_CANVAS
})
]);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dedup key in loadConnectorParameterContextOnLoadSuccess$ is (connectorId, processGroupId) — both fields must change (or one must change) to pass the distinctUntilChanged predicate. This test covers the PG-changes case, but the symmetric case — same PG id, different connector — is missing. Without it, someone could simplify the predicate to check only processGroupId and no test would fail, even though navigating between two connectors with the same root PG id would silently skip refetching the parameter context.

Suggested addition:

it('should dispatch again when the connector changes even if the process group id is the same', async () => {
    const { effects, actions$ } = await setup();
    actions$(
        of(
            flowSuccessAction('connector-123', 'pg-abc'),
            flowSuccessAction('connector-456', 'pg-abc')
        )
    );

    const emitted = await firstValueFrom(effects.loadConnectorParameterContextOnLoadSuccess$.pipe(toArray()));

    expect(emitted).toEqual([
        loadConnectorParameterContext({
            connectorId: 'connector-123',
            processGroupId: 'pg-abc',
            errorContext: ErrorContextKey.CONNECTOR_CANVAS
        }),
        loadConnectorParameterContext({
            connectorId: 'connector-456',
            processGroupId: 'pg-abc',
            errorContext: ErrorContextKey.CONNECTOR_CANVAS
        })
    ]);
});

@rfellows
Copy link
Copy Markdown
Contributor

Also, I didn't see another JIRA anywhere to account for the same behavior for Controller Services used by connectors. If they use parameters, we don't get the same tooltip resolution:

Screenshot 2026-05-22 at 08 53 56

Copy link
Copy Markdown
Contributor

@rfellows rfellows left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing...

Comment on lines +218 to +221
distinctUntilChanged(
(previous, current) =>
previous.connectorId === current.connectorId && previous.processGroupId === current.processGroupId
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NgRx effects are application-level singletons — they're created once when the module initializes and run forever. They are never destroyed or recreated when a component mounts/unmounts.

The distinctUntilChanged inside the effect accumulates its previous value for the entire app lifetime. This results in the parameter-context lookup to only get loaded when the browser loads the connector canvas directly (deep link). If you navigate to it from the connector listing, the parameters don't get loaded.

The store resets (resetConnectorCanvasState in ngOnDestroy) clear parameterContext from the state, but that does nothing to clear the effect's internal previous reference. From the effect's perspective the (connectorId, processGroupId) pair hasn't changed, so it silently suppresses the re-fetch.

The fix
The effect needs to restart its inner distinctUntilChanged whenever the canvas state is reset. The idiomatic RxJS pattern for this is startWith + switchMap on the reset action:

loadConnectorParameterContextOnLoadSuccess$ = createEffect(() =>
    this.actions$.pipe(
        ofType(ConnectorCanvasActions.resetConnectorCanvasState),
        startWith(null),   // kick off the first inner subscription immediately
        switchMap(() =>    // each reset discards the old inner sub (and its stale distinctUntilChanged state) and starts fresh
            this.actions$.pipe(
                ofType(
                    ConnectorCanvasActions.loadConnectorFlowSuccess,
                    ConnectorControllerServicesActions.loadConnectorControllerServicesSuccess
                ),
                map((action) => { /* normalize */ }),
                filter((target): target is ... => target.processGroupId != null),
                distinctUntilChanged(
                    (previous, current) =>
                        previous.connectorId === current.connectorId &&
                        previous.processGroupId === current.processGroupId
                ),
                map((target) => ConnectorCanvasActions.loadConnectorParameterContext(target))
            )
        )
    )
);

When resetConnectorCanvasState fires (component ngOnDestroy), switchMap cancels the inner observable — clearing the stale previous — and subscribes to a fresh one. The next loadConnectorFlowSuccess is then the first emission in that new inner stream, so distinctUntilChanged lets it through.

The startWith(null) is essential: without it, the effect wouldn't start listening for load-success actions until the first reset fires.

@mcgilman
Copy link
Copy Markdown
Contributor Author

Also, I didn't see another JIRA anywhere to account for the same behavior for Controller Services used by connectors. If they use parameters, we don't get the same tooltip resolution:

Screenshot 2026-05-22 at 08 53 56

This was actually caused by the other iterms you called out. It's been addressed in my most recent commit.

Copy link
Copy Markdown
Contributor

@rfellows rfellows left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feature @mcgilman 👍
This is very useful.

@rfellows rfellows merged commit b2f332b into apache:main May 22, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui Pull requests for work relating to the user interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants